Feat/scaleset label change - #306
Conversation
`tox -e fmt` runs `ruff format` over `src`, which includes the Python snippet in the generated `garm_client_README.md`. The snippet had never been formatted, so `tox -e lint` (`ruff format --check`) failed on it regardless of what a change touched. This is generated output, so `scripts/generate_client.sh` will undo it on the next client regeneration — running `tox -e fmt` afterwards restores it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Labels are immutable once a GitHub scale set exists, so a label change in the garm-configurator relation data only logged a warning telling the operator to remove and re-add the relation. A label change is now applied by replacement. The live scaleset name carries a hash of its labels, so the name a spec should have is a pure function of the spec: every reconcile re-derives which live scaleset is current and which are replaced predecessors, with no charm-side state to persist, which is what makes the flow restart-safe and idempotent. The changeover is staged across reconciles and never blocks a hook: - the replacement is created first, so every label carried by both generations is served throughout; - the old scaleset is disabled only once the replacement is observed live, which closes its listener session and stops it receiving new jobs while runners already mid-job are left to finish; - it is deleted only after its runner count reaches zero. A scaleset draining past DRAIN_DEADLINE (7h, beyond GitHub's 6h job cap) holds a runner that is stuck rather than busy, so the delete is attempted regardless; GARM rejects the call while runners are genuinely active, so this cannot cut a job short but does stop a permanently faulted instance pinning a dead scaleset on GitHub. Progress is reported as a MaintenanceStatus naming the scaleset the operator configured and the phase it is in (creating replacement / draining N runners / awaiting deletion), returning to active once the replacement is serving and the old scaleset is gone. Also hardens the surrounding pass: a GarmApiError on one spec no longer aborts the others or the orphan sweep (it is re-raised after the pass, so the charm still reports the sync failed), a connection error still aborts immediately, and duplicate desired names are dropped rather than letting two specs retire each other's scaleset forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reconciler uses "spec" for a ScalesetSpec throughout, so "the desired specs" read as charm-side desired state rather than the GARM extra_specs the sentence is about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the garm charm’s scaleset reconciliation logic to support GitHub scaleset label changes via a blue/green-style replacement flow (create replacement, hand over, drain, delete), and documents the design decision via a new ADR.
Changes:
- Implement label-hash-based “generation” naming for scalesets and a multi-reconcile replacement/drain/delete workflow in the scaleset reconciler.
- Surface in-progress replacement state to Juju unit status and update integration/unit tests to assert the new behavior.
- Add documentation (ADR + changelog) describing the rationale, operational behavior, and constraints.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/changelog.md | Adds a user-facing changelog entry describing automatic scaleset label-change handling. |
| docs/adr/003_scaleset_label_change_by_replacement.md | New ADR documenting the replacement-based approach and its operational constraints. |
| charms/tests/integration/test_garm.py | Updates integration assertions/helpers to handle label-hashed live scaleset names. |
| charms/garm/tests/unit/test_scaleset_reconciler.py | Adds extensive unit coverage for replacement/drain semantics and edge cases. |
| charms/garm/tests/unit/test_garm_api.py | Adds unit tests for the new instances-listing client call used to gate deletion. |
| charms/garm/tests/unit/test_charm.py | Adds unit coverage for reporting replacement progress in Juju status. |
| charms/garm/src/scaleset_reconciler.py | Core implementation: generation naming, family resolution, replacement/drain orchestration, and defensive deletion gating. |
| charms/garm/src/garm_client_README.md | Updates the generated client README snippet (currently contains a garbled line in the snippet). |
| charms/garm/src/garm_api.py | Adds list_scaleset_instances wrapper around the Instances API for runner counting. |
| charms/garm/src/charm.py | Uses reconciler progress to emit maintenance status while replacements are in flight. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| digest = hashlib.sha256(logical_name.encode("utf-8")).hexdigest()[:LABEL_HASH_LENGTH] | ||
| return f"{logical_name[: limit - LABEL_HASH_LENGTH - 1]}-{digest}" |
There was a problem hiding this comment.
i understand the algo, just a question: if the goal of hashing is to avoid collision when the truncated name share the same prefix, then why not just return the hash itself for the entirety of the limit length
There was a problem hiding this comment.
There's a name length limit i believe on GARM/GitHub side
| if observed_name == logical_name: | ||
| return True |
There was a problem hiding this comment.
what about for the scalesets created before label-hashed naming that carries the unsuffixed name n also <= limit length? it will fall into this and be marked as true (shld it be false bc it's not generated?)
There was a problem hiding this comment.
There is a code path for the unsuffixed legacy names.
| The specs with duplicate names removed, first occurrence winning. Two | ||
| specs sharing a name would each own the other's live scaleset and retire | ||
| it on every reconcile, so they would replace each other forever. |
There was a problem hiding this comment.
forever as in forever? is there a way to just have one spec per a unique name?
There was a problem hiding this comment.
I think this should be caused by multiple deployments using the same scaleset name. I do not think it can be avoided via charm code. In deployments, we should not be doing that.
| its deletion, which is a distinct thing to be waiting on. | ||
| """ | ||
| if not progress.handed_over: | ||
| return "creating replacement" |
There was a problem hiding this comment.
Would it help separate the phases to be a constant or an enum?
There was a problem hiding this comment.
Changed to constants.
| self._reconcile_one( | ||
| spec, providers, observed, templates, families.get(spec.name, []) | ||
| ) |
There was a problem hiding this comment.
Just out of curiosity, how long is this operation expected to take? I'm asking because if it's a long running operation, if there would be a way to do fire & forget?
| create_params = self._to_create_params(spec) | ||
| create_params = self._to_create_params(spec, active_name) | ||
| except Exception as exc: | ||
| logger.warning("Skipping scaleset %s: spec validation failed: %s", spec.name, exc) |
There was a problem hiding this comment.
Should this be an error log? Warning may go unnoticed it seems
There was a problem hiding this comment.
Changed to error level.
There was a problem hiding this comment.
Just OOC, i'm guessing we won't test blue/green deployment since its a huge costly test?
There was a problem hiding this comment.
It would be a long test. Maybe in the release gate?
cbartz
left a comment
There was a problem hiding this comment.
Read the ADR first as suggested — the design is sound and the naming/family logic is well covered by the unit tests. Two things I'd want resolved before this lands: a wire-format issue that I believe stops the changeover from ever completing, and one claim in the ADR that the cited GARM source doesn't actually establish. Details inline.
One non-blocking aside that didn't fit on a diff line — _needs_update compares observed.min_idle_runners != spec.min_idle_runners, and MinIdleRunners is omitempty in GARM (params/params.go:637), so a scaleset configured with min_idle_runners: 0 reads back as None and None != 0 makes the check true on every pass — a no-op update call each reconcile, forever. Pre-existing, not from this PR, and the same root cause as the first inline comment. Fine to split out.
This review was generated with AI assistance.
| ScalesetProgress(spec.name, name, active_name, 0, handed_over=False) | ||
| ) | ||
| continue | ||
| if old.enabled is not False: |
There was a problem hiding this comment.
if old.enabled is not False:
self._retire(old)I don't think this ever becomes False in production, which would mean the predecessor is never deleted.
GARM tags the field omitempty on the model this endpoint returns (params/params.go:642, swagger:model ScaleSet):
Enabled bool `json:"enabled,omitempty"`There's no custom MarshalJSON, so a disabled scaleset comes back with no enabled key at all, and the generated client models it as Optional[StrictBool] = None (src/garm_client/models/scale_set.py:39).
So on the reconcile after cutover, old.enabled is None, which is not False → we re-enter _retire and continue, never reaching the runner-count check or _delete_drained. The scaleset stays disabled forever, the unit sits in MaintenanceStatus reporting "awaiting deletion", and the dead scale set stays registered on GitHub. DRAIN_DEADLINE can't rescue it either — that code is downstream of the same branch.
Inverting the test to the truthy form handles both shapes:
if old.enabled:
self._retire(old)replacement_live above is fine as-is — Go only omits the false case, so enabled is True still holds for an enabled scaleset.
Worth a quick confirmation against a live GARM, but the struct tag looks conclusive.
This review was generated with AI assistance.
There was a problem hiding this comment.
This one should be fixed as well.
| _NEW_NAME = target_scaleset_name("my-scaleset", _LABELS_NEW) | ||
|
|
||
|
|
||
| def _generation(labels, **overrides): |
There was a problem hiding this comment.
This is why the suite doesn't catch the enabled issue I flagged in scaleset_reconciler.py.
_existing_scaleset sets enabled=True and every drain/delete test overrides it to enabled=False (e.g. test_draining_scaleset_is_not_deleted_while_runners_remain). Because of the omitempty behaviour on GARM's ScaleSet model, that's a state the real API can't return, so the whole retirement half of the suite is green against a shape that doesn't occur.
Could the fixture model the wire instead — drop the key when disabled rather than setting False? That would make these tests exercise the real path, and it'd guard the other omitempty fields on the same model from the same class of bug.
This review was generated with AI assistance.
| 3. While the predecessor reports runners, report progress and take no action. | ||
| 4. When it reports no runners, delete it and its runner template. | ||
|
|
||
| Disabling closes the predecessor's listener session, ending job assignment to it. |
There was a problem hiding this comment.
Disabling closes the predecessor's listener session, ending job assignment to it.
The first half checks out, and more strongly than the ADR claims — listener.Stop() calls scaleSetClient.DeleteMessageSession(...) (workers/scaleset/scaleset_listener.go:123), so the session is deleted GitHub-side, not just dropped locally. handleScaleSetEvent stops it on !Enabled (scaleset.go:607) and keepListenerAlive won't restart it while disabled (sessionLoopMayRun, plus the two re-checks in the restart path). No argument there.
The second half — "ending job assignment to it" — is the part I can't find support for. The scale set still exists on GitHub after the disable, with the same labels in the same runner group, and assignment happens GitHub-side at queue time. Deleting the session stops GARM receiving; whether it stops GitHub assigning is undocumented GitHub behaviour that GARM's source can't tell us.
If GitHub does still assign, the failure mode is concrete: a job routed to the retiring scale set after the disable is never delivered, because GARM won't reopen the session to resume from last_message_id. It hangs until the scale set is deleted — up to DRAIN_DEADLINE by design. With both generations carrying overlapping labels for the whole drain, that wouldn't be a rare edge case.
This is load-bearing for the "no queue gap" argument, and it's the only one of the four listed behaviours without a code citation. Could we get either a reference or an observed run showing that jobs queued during a drain land on the replacement?
This review was generated with AI assistance.
There was a problem hiding this comment.
This should be fixed now.
| return 0 | ||
| try: | ||
| return len(self._client.list_scaleset_instances(scaleset.id)) | ||
| except GarmApiError as exc: |
There was a problem hiding this comment.
reconcile's docstring says a connection error isn't contained ("GARM is down, so retrying every remaining spec would only stall the hook"), and reconcile re-raises it before the GarmApiError handler. But GarmConnectionError subclasses GarmApiError (garm_api.py:58), so the new drain helpers swallow it:
_remaining_runners(here) returns1and reports "still draining"_retirelogs and moves on_delete_drainedreturnsFalse
With GARM down mid-pass, the charm reports maintenance progress rather than "GARM sync failed". It self-corrects on retry, so this is non-blocking, but it masks the outage and diverges from the documented behaviour. An explicit except GarmConnectionError: raise ahead of each of these three would line them up with reconcile.
This review was generated with AI assistance.
There was a problem hiding this comment.
The functions now raises the connection errors.
| # outlives this hook: report progress and let update-status converge it. | ||
| if replacing: | ||
| self.update_app_and_unit_status( | ||
| ops.MaintenanceStatus(_scaleset_replacement_status(replacing)) |
There was a problem hiding this comment.
Is maintenance the right status for a drain that can run for hours?
This puts app and unit into MaintenanceStatus for the whole drain, which the ADR notes can reach DRAIN_DEADLINE. The service is fully functional throughout — the replacement is serving, nothing is degraded — so anything that waits for active (integration tests, juju wait-for, upgrade orchestration) blocks on an unrelated background convergence.
Would ActiveStatus("Replacing scaleset …") communicate the same thing without that side effect? The ADR lists the consequence but doesn't argue the choice, so I may be missing a reason.
This review was generated with AI assistance.
There was a problem hiding this comment.
Changed to active status.
|
|
||
| scaleset = _wait_for_scaleset(base_url, token, _SCALESET_TEST_NAME) | ||
| assert scaleset["name"] == _SCALESET_TEST_NAME | ||
| assert re.fullmatch( |
There was a problem hiding this comment.
This adapts the existing assertion to hashed names, but nothing exercises the changeover itself. Given the design leans on GARM/GitHub behaviours that unit fixtures can't reproduce — the enabled comment on scaleset_reconciler.py being the case in point — I think one end-to-end pass is worth it.
It should be cheap here: the suite already runs GARM against the mock GitHub API (_point_github_endpoint_at_mock), and no VMs are involved — the provider binary is only checked for presence, so scalesets have zero instances and the drain completes immediately. Change the label in the configurator relation data, let update-status run twice, assert one enabled generation remains and the old one is gone. Roughly two extra hook cycles on an already-deployed model.
This review was generated with AI assistance.
There was a problem hiding this comment.
I think we had a discussion on this. The team looking into the (ARC)[https://github.com/actions/actions-runner-controller] and GARM source code and it seems that disabling the scaleset was enough.
|
|
||
| ## Consequences | ||
|
|
||
| Live names carry a hash suffix, so `garm-cli scaleset list` reports `my-scaleset-1a2b3c4d`. |
There was a problem hiding this comment.
One consequence worth adding: both generations carry the full min_idle_runners for the duration, so the idle runner count doubles during the drain. Against a fixed OpenStack quota the replacement may be unable to spawn runners at all — which would then also stall the changeover it's supposed to complete. Worth a line even if the answer is "operators should size for it".
This review was generated with AI assistance.
GARM tags ScaleSet.Enabled `omitempty` with no custom marshaller, so a disabled scaleset comes back with no `enabled` key and the generated client reads it as None. `old.enabled is not False` was therefore never false in production: the drain re-entered _retire on every pass and never reached the runner-count check or the delete, leaving the predecessor disabled forever and registered on GitHub. Compare truthily instead. The unit fixture set `enabled=False`, a shape the API cannot return, which is why the suite stayed green against it; it now drops the key like the wire does. The same comparison in the integration helper _find_scaleset counted a draining predecessor as still serving, and is fixed alongside. Also from review: - Re-raise GarmConnectionError from the three drain helpers. It subclasses GarmApiError, so the containing handlers reported drain progress while GARM was down, diverging from reconcile()'s documented behaviour. - Report the drain as active rather than maintenance. Every label is served throughout, so blocking `juju wait-for` and integration tests on hours of healthy background convergence is the wrong trade. - Cover the changeover end to end in the integration suite: no VMs are involved, so the drain completes as fast as the charm reconciles. - Log a malformed spec at error level; unlike the provider/entity gates it will not resolve on its own. - Name the replacement phases; apply the review suggestion for `claimed`. - ADR: claim only what the GARM source establishes about the listener session, and record the GitHub-side job-assignment question as open. Note that both generations carry min_idle_runners for the whole drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What this PR does
AI summary
Copilot kept crashing generating summary...
Why we need it
This closes the issue with changing labels for scaleset.
Checklist
CONTRIBUTING.mdhas been updated upon changes to the contribution/development process (e.g. changes to the way tests are run)docs/changelog.mdwith user-relevant changes(e.g., in
.github/workflows/integration_tests.yaml, ensure themoduleslist is correct)terraform fmtpasses andtflintreports no errorsAGENTS.md.copilot-collections.yamlor.github/instructions/: I re-checked whether theAGENTS.md"12-factor divergences" guidance still matches the upstream copilot-collections guidance